contents
"상속보다 컴포지션"은 객체 지향 프로그래밍의 근본적인 디자인 원칙으로, 클래스 상속("is-a" 관계)보다 컴포지션("has-a" 관계)을 선호해야 한다는 것을 말합니다.
이 원칙은, 복잡한 기능을 구축할 때 부모 클래스로부터 _상속_받는 것보다, 필요한 기능을 제공하는 다른 클래스의 인스턴스를 _포함_하는 것(컴포지션)이 종종 더 유연하고, 유지보수하기 좋으며, 견고한 방법이라고 제안합니다.
각 개념, 상속의 문제점, 그리고 왜 컴포지션이 더 나은 선택인지 자세히 살펴보겠습니다.
1. 상속이란 무엇인가? ("IS-A" 관계)
상속은 기존 클래스(상위 클래스 또는 부모 클래스)를 기반으로 새로운 클래스(하위 클래스 또는 자식 클래스)를 형성하는 방법입니다. 자식 클래스는 부모의 모든 필드와 메서드를 물려받아 코드를 재사용하고 명확한 "is-a" 계층을 만들 수 있게 합니다.
- 예시:
Dog는Animal이다.Car는Vehicle이다. - 목적: 코드를 재사용하고 다형성(예:
Dog객체를Animal객체로 취급하는 능력)을 활성화하기 위함입니다.
상속의 문제점
상속은 간단한 경우에는 유용하지만, 시스템이 커짐에 따라 다음과 같은 몇 가지 주요 단점이 나타납니다.
- 강한 결합 (Tight Coupling): 자식 클래스는 부모 클래스의 구현 에 강하게 결합됩니다. 만약 부모 클래스가 수정되면(예: 메서드 변경, 새 메서드 추가), 모든 자식 클래스의 동작이 예기치 않게 깨지거나 변경될 수 있습니다. 이를 깨지기 쉬운 기반 클래스(Fragile Base Class) 문제라고 합니다.
- 경직성 (Rigidity): 상속 계층은 컴파일 타임에 정의됩니다. 런타임에 객체의 클래스를 변경할 수 없습니다. 객체가 자신의 동작을 변경해야 할 때, 상속은 유연하지 못합니다.
- "고릴라-바나나" 문제: 얼랭(Erlang)의 창시자인 조 암스트롱의 유명한 인용구가 이 문제를 잘 설명합니다: "당신은 바나나를 원했지만, 바나나를 들고 있는 고릴라와 정글 전체를 얻게 되었다." 상속을 사용하면, 기능의 작은 부분만 필요했음에도 불구하고 부모의 _모든 것_을 물려받게 됩니다.
- "죽음의 다이아몬드" (다중 상속): C++나 파이썬처럼 클래스가 여러 부모로부터 상속받는 것을 허용하는 언어에서는 "죽음의 다이아몬드" 문제에 부딪힐 수 있습니다. 만약
Class B와Class C가 모두Class A를 상속하고,Class D가B와C를 모두 상속한다면,D는A의 메서드를B와C중 어느 쪽의 구현으로 사용해야 할까요? 이는 모호함과 복잡성을 만듭니다.
2. 컴포지션이란 무엇인가? ("HAS-A" 관계)
컴포지션은 필요한 기능을 제공하는 다른 클래스의 인스턴스를 _포함_함으로써 클래스를 구축하는 방식입니다. 동작을 상속받는 대신, 클래스가 자신의 구성 요소에게 작업을 _위임_합니다.
- 예시:
Car는Engine을 가지고 있다.Car객체는 엔진이 _되는 것_이 아니라, 엔진 객체를 사용 합니다.car.start()를 호출하면,Car클래스는 이 호출을 자신의engine.start()메서드에 위임합니다. - 목적: 부품을 조립하듯이 코드를 재사용하고, 유연성과 캡슐화를 증진시키기 위함입니다.
컴포지션이 더 나은 이유
컴포지션은 상속의 문제점들을 직접적으로 해결합니다.
- 유연성 (Flexibility): 관계가 런타임에 정의될 수 있습니다. 클래스에 다른 구성 요소를 주입하여 동작을 변경할 수 있습니다. 예를 들어,
Car객체의Engine구성 요소를 런타임에 다른 것으로 교체할 수 있습니다. - 느슨한 결합 (Loose Coupling): "컨테이너" 클래스(
Car)는 "구성 요소" 클래스(Engine)의 공개 인터페이스(public interface)를 통해서만 상호작용합니다. 구성 요소의 내부 구현을 알 필요도 없고 신경 쓰지도 않으므로, 시스템이 훨씬 더 모듈화됩니다. - 캡슐화 (Encapsulation): 내부 구성 요소들은 외부 세계로부터 숨겨집니다.
Car클래스가Engine에 대한 모든 접근을 제어하며, 깨끗하고 안정적인 인터페이스를 제공합니다. - 고릴라-바나나 문제 해결: 필요한 "바나나"(특정 구성 요소)만 가져와서 클래스에 포함시키면 됩니다. 정글 전체를 얻을 필요가 없습니다.
- 다중 상속 문제 없음: 원하는 만큼 많은 객체를 조합(compose)할 수 있으므로, 다중 상속의 복잡성 없이 그 "이점"을 효과적으로 달성할 수 있습니다.
상세 예제: "로봇" 문제
다양한 유형의 로봇이 있는 시스템을 설계해 봅시다. 로봇은 움직이고 작업을 수행해야 합니다.
상속 기반 접근 (나쁜 예)
먼저 Robot이라는 기반 클래스를 만들고 다른 로봇들이 이를 상속받도록 할 수 있습니다.
// 기반 클래스
abstract class Robot {
public void move() {
System.out.println("걷는 중...");
}
public abstract void performTask();
}
// 자식 클래스들
class CleanerRobot extends Robot {
@Override
public void performTask() {
System.out.println("바닥 청소 중.");
}
}
class KillerRobot extends Robot {
@Override
public void performTask() {
System.out.println("타겟 제거 중.");
}
}
괜찮아 보이지만, 새로운 요구사항이 생겼습니다.
FlyingRobot이 필요합니다.fly()메서드를 어떻게 추가할까요? 만약Robot기반 클래스에 추가하면,CleanerRobot도fly()를 상속받게 되는데 이는 말이 되지 않습니다.FlyingKillerRobot이 필요합니다. 이제 큰 문제가 생겼습니다.FlyingRobot과KillerRobot둘 다를 상속받을 수 없습니다. 이 상속 계층은 경직되어 있으며 이미 한계에 부딪혔습니다.
컴포지션 기반 접근 (좋은 예)
로봇이 무엇인지 가 아니라, 무엇을 하는지 에 대해 생각해 봅니다. 로봇의 행동들을 별도의 교체 가능한 "전략" 객체로 정의합니다.
1단계: 행동 인터페이스 정의
interface MoveBehavior {
void move();
}
interface TaskBehavior {
void perform();
}
2단계: 구체적인 행동 클래스 생성
// 움직임 행동
class WalkBehavior implements MoveBehavior {
public void move() { System.out.println("걷는 중..."); }
}
class FlyBehavior implements MoveBehavior {
public void move() { System.out.println("하늘을 나는 중!"); }
}
// 작업 행동
class CleanBehavior implements TaskBehavior {
public void perform() { System.out.println("바닥 청소 중."); }
}
class KillBehavior implements TaskBehavior {
public void perform() { System.out.println("타겟 제거 중."); }
}
3단계: Robot 클래스를 컴포지션으로 구성
Robot 클래스는 움직임 행동을 가지고 있고, 작업 행동을 가지고 있습니다.
class Robot {
private MoveBehavior moveBehavior;
private TaskBehavior taskBehavior;
// 생성 시 행동들을 "주입"받음
public Robot(MoveBehavior m, TaskBehavior t) {
this.moveBehavior = m;
this.taskBehavior = t;
}
// Robot이 자신의 구성 요소에게 작업을 위임
public void doMove() {
this.moveBehavior.move();
}
public void doTask() {
this.taskBehavior.perform();
}
// 런타임에 행동을 변경할 수도 있습니다!
public void setMoveBehavior(MoveBehavior m) {
this.moveBehavior = m;
}
}
이제, 새로운 로봇을 만드는 것이 매우 유연해졌습니다.
// 걷는 청소 로봇
Robot cleaner = new Robot(new WalkBehavior(), new CleanBehavior());
cleaner.doMove(); // "걷는 중..."
cleaner.doTask(); // "바닥 청소 중."
// 복잡했던 "나는 킬러 로봇"이 이제 쉬워졌습니다!
Robot killer = new Robot(new FlyBehavior(), new KillBehavior());
killer.doMove(); // "하늘을 나는 중!"
killer.doTask(); // "타겟 제거 중."
// 즉석에서 행동을 변경할 수도 있습니다
killer.setMoveBehavior(new WalkBehavior());
killer.doMove(); // "걷는 중..."
상속은 언제 사용해도 괜찮은가?
이 원칙은 "절대 상속을 쓰지 말라"가 아니라, "컴포지션을 선호하라"는 것입니다. 상속은 다음과 같은 몇 가지 특정 경우에 여전히 유효하고 유용한 도구입니다.
- 진정한 "IS-A" 관계일 때: 하위 클래스가 단지 행동의 집합이 아니라 상위 클래스의 진정한 "is-a" 전문화일 때 (예:
Dog와Cat은 둘 다Animal이다). - 다형성을 위할 때: 서로 다른 객체들을 동일한 방식으로 다루고 싶을 때 (예:
Dog와Cat객체를 모두 담는List<Animal>). - 프레임워크 확장을 위해: 종종 프레임워크를 사용할 때 기반 클래스를 상속하도록 요구받습니다 (예: 리액트의
class MyComponent extends React.Component).
좋은 경험 법칙:
- 상속은 객체가 무엇인지 를 모델링할 때 사용합니다.
- 컴포지션은 객체가 무엇을 하는지 를 모델링할 때 사용합니다.
references